跳到主要内容

Java IO学习-NIO 核心组件及基本概念

概述

从 JDK1.4 开始,Java 提供了一系列改进的输入/输出处理的新特性,被统称为 NIO(即New I/O,也可以称为 Non Blocking IO)。新增了许多用于处理输入输出的类,这些类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写,新增了满足 NIO 的功能。NIO 采用内存映射文件的方式来处理输入输出,NIO 将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样访问文件了。

Java NIO(New IO) 是从 Java 1.4 版本开始引入的一个新的 IO API,可以替代标准的 Java IO API。NIO 与原来的 IO 有同样的作用和目的,但是使用的方式完全不同, NIO 支持面向缓冲区的、基于通道的 IO 操作。 NIO 将以更加高效的方式进行文件的读写操作。

下面前面的几节主要介绍它和传统的 BIO 有啥子区别

什么是 NIO、BIO、AIO

BIO (Blocking I/O):同步阻塞I/O模式。 NIO (New I/O):同步非阻塞模式。 AIO (Asynchronous I/O):异步非阻塞I/O模型。

什么是 BIO

BIO 全称是 Blocking IO,是 JDK1.4 之前的传统 IO 模型,本身是同步阻塞模式。

image.png

线程发起 IO 请求后,一直阻塞 IO,直到缓冲区数据就绪后,再进入下一步操作。针对网络通信都是一请求一应答的方式,虽然简化了上层的应用开发,但在性能和可靠性方面存在着巨大瓶颈,试想一下如果每个请求都需要新建一个线程来专门处理,那么在高并发的场景下,机器资源很快就会被耗尽。

什么是 NIO

NIO 也叫 Non-Blocking IO 是同步非阻塞的 IO 模型。线程发起 IO 请求后,立即返回(非阻塞 IO)。

同步指的是必须等待 IO 缓冲区内的数据就绪,而非阻塞指的是,用户线程不原地等待 IO 缓冲区,可以先做一些其他操作,但是要定时轮询检查 IO 缓冲区数据是否就绪。Java 中的 NIO 是 new IO 的意思。

NIO 主要有 buffer、channel、selector 三种技术的整合,通过零拷贝的 buffer 取得数据,每一个客户端通过 channel 在 selector(多路复用器)上进行注册。服务端不断轮询 channel 来获取客户端的信息。

image.png

channel 上有 connect(连接)、accept(阻塞)、read(可读)、write(可写)四种状态标识。根据标识来进行后续操作。所以一个服务端可接收无限多的 channel。不需要新开一个线程。大大提升了性能。

什么是 AIO

AIO 即 Asynchronous I/O(异步 I/O),这是 Java 1.7 引入的 NIO 2.0 中用到的。整个过程中,用户线程发起一个系统调用之后无须等待,可以处理别的事情。由操作系统等待接收内容,接收后把数据拷贝到用户进程中,最后通知用户程序已经可以使用数据了,两个阶段都是非阻塞的。AIO 整个过程如下图:

image.png

AIO属于异步模型, 用户线程可以同时处理别的事情,我们怎么进一步加工处理结果呢? Java 在这个模型中提供了两种方法:

  • 一种是基于“回调”,可以实现 CompletionHandler 接口,在调用时把回调函数传递给对应的 API 即可
  • 另一种是返回一个 Future。处理完别的事情,可以通过 isDone() 可查看是否已经准备好数据,通过 get() 方法等待返回数据。

传统的 BIO 流模型

参考资料 Java I/O体系从原理到应用,这一篇全说清楚了 参考资料 15.2.2 流的概念模型

BIO 全称是 Blocking IO,是 JDK1.4 之前的传统 IO 模型,就是同步阻塞 IO

Java 把所有设备里的有序数据抽象成流模型,简化了输入/输岀处理,理解了流的概念模型也就了解了 Java IO

计算机中的数据是基于随着时间变换高低电压信号传输的,这些数据信号连续不断(诸如计算机中存储的视频、音频、文件等,底层都是一串串的 0 和 1),有着固定的传输方向,类似水管中水的流动,因此抽象数据流(I/O流)的概念:指一组有顺序的、有起点和终点的字节集合

image.png

抽象出数据流的作用:实现程序逻辑与底层硬件解耦,通过引入数据流作为程序与硬件设备之间的抽象层,面向通用的数据流输入输出接口编程,而不是具体硬件特性,程序和底层硬件可以独立灵活替换和扩展

  • InputStream、Reader:所有输入流的基类,前者是字节输入流,后者是字符输入流。
  • OutputStream、Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。

输入流模型

对于 InputStream 和 Reader 而言,它们把输入设备抽象成一个 “水管”,这个水管里的每个 “水滴依次排列”

image.png

字节流和字符流的处理方式其实非常相似,只是它们处理的输入输出单位不同而已。输入流使用隐式的记录指针来表示当前正准备从哪个 “水滴” 开始读取,每当程序从 InputStream 或 Reader 里取出一个或多个“水滴”后,记录指针自动向后移动;除此之外,InputStream 和 Reader 里都提供一些方法来控制记录指针的移动。

输出流模型

对于 OutputStream 和 Writer 而言,它们同样把输出设备抽象成一个“水管”,只是这个水管里没有任何水滴

image.png

当执行输出时,程序相当于依次把“水滴”放入到输出流的水管中,输出流同样采用隐式的记录指针来标识当前水滴即将放入的位置,每当程序向 OutputStream 或 Writer 里输出或多个水滴后,记录指针自动向后移动。

NIO 和 IO有什么区别

IONIO
面向流编程面向块(缓冲区)编程
阻塞 IO非阻塞 IO
选择器 selector

NIO 主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector。传统 IO 基于字节流和字符流进行操作,而 NIO 基于 Channel 和 Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。

NIO 和传统 IO(一下简称IO)之间第一个最大的区别是,IO 是面向流的,NIO 是面向缓冲区的。 Java IO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。

BIO 阻塞机制是怎样的?

BIO 阻塞机制:BIO 中 客户端 与 服务器端 进行交互,需要阻塞等待服务器的响应,服务器在建立连接后,也需要阻塞等待客户端的后续数据。

那 NIO 是如何非阻塞的?

NIO 非阻塞机制:客户端请求服务器端后,将请求数据写入服务器端的 缓冲区(Buffer)中,服务器端通过 选择器(Selector)轮询 通道(Channel),查询 缓冲区(Buffer)中是否有请求数据,客户端不用阻塞等待服务器端响应,服务器端也不用阻塞等待客户端的请求,因此这里实现了非阻塞机制。

说白了就是一个事件模型(观察者模式)

NIO 的三大组件

这一节先简单的概述 NIO 的三大组件它们之间的作用,具体的细节看下面

Java NIO 系统传输的核心在于:通道(Channel)和缓冲区(Buffer)。

这两者的关系:若需要使用 NIO 系统,首先需要获取用于连接 IO 设备的通道,以及用于容纳数据的缓冲区,然后操作缓冲区,对数据进行处理

注:通道表示打开 IO 设备(例如:文件、套接字)的连接。

而调度这些通道(Channel)就是通过选择器 Selector 来完成,它会根据客户端请求,选择指定的通道(Channel)为客户端进行服务

简而言之,通道负责传输,缓冲区负责存储

Channel 通道

它代表一个到实体(如一个硬件设备、一个文件、一个网络套接字或者一个能够执行一个或者多个不同的 IO 操作的程序组件)的开放连接,如读操作和写操作。

目前,可以把 Channel 看作是传入(入站)或者传出(出站)数据的载体。因此,它可以被打开或者被关闭,连接或者断开连接。

常见的 Channel 有以下四种,其中 FileChannel 主要用于文件传输,其余三种用于网络通信

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

Selector 选择器

Selector(选择器)也是 Java NIO 中的一个组件,用于检查一个或多个 NIO Channel 的状态是否处于可读、可写。如此可以实现单线程管理多个 Channels,也就是可以管理多个网络链接。

在使用 Selector 之前,处理 socket 连接还有以下两种方法

使用多线程技术

为每个连接分别开辟一个线程,分别去处理对应的 socket 连接

这种方法存在以下几个问题

1、内存占用高:每个线程都需要占用一定的内存,当连接较多时,会开辟大量线程,导致占用大量内存 2、线程上下文切换成本高 3、只适合连接数少的场景:连接数过多,会导致创建很多线程,从而出现问题

使用线程池技术

这种方式实际就是上面那种方式的改良版

这种方法依旧存在以下几个问题

阻塞模式下,线程仅能处理一个连接

  • 线程池中的线程获取任务(task)后,只有当其执行完任务之后(断开连接后),才会去获取并执行下一个任务
  • 若 socket 连接一直未断开,则其对应的线程无法处理其他 socket 连接

所以它更适用于 短连接场景

短连接即建立连接发送请求并响应后就立即断开,使得线程池中的线程可以快速处理其他连接

使用 Selector

了解了上面的两种传统维持多连接的局限性后,现在开始谈谈 NIO 的核心概念 selector(选择器)

selector 的作用就是配合一个线程来管理多个 channel(fileChannel 因为是阻塞式的,所以无法使用 selector),获取这些 channel 上发生的事件,这些 channel 工作在 非阻塞模式下,当一个 channel 中没有执行任务时,可以去执行其他 channel 中的任务。

它适合连接数多,但流量较少的场景

若事件未就绪,调用 selector 的 select() 方法阻塞线程,直到 channel 发生了就绪事件。这些事件就绪后,select 方法就会返回这些事件交给 thread 来处理

Buffer 缓冲区

学习之前要理解 Buffer 是一个用来装数据的容器

Buffer 有以下几种,其中使用较多的是 ByteBuffer

  • ByteBuffer
    • MappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
  • CharBuffer

它们之间的继承关系如下:

使用步骤

1、向 buffer 写入数据,例如调用 channel.read(buffer) 2、调用 flip() 切换至 读模式 3、从 buffer 读取数据,例如调用 buffer.get() 4、调用 clear() 或者 compact() 切换至 写模式

  • 调用 clear() 方法时会直接清空 Buffer,使之初始化
  • 调用 compact() 方法时,会将缓冲区中的未读数据压缩到缓冲区前面

重复 1~4 步骤

使用案例

这里简单的使用:

public class Test {
public static void main(String[] args) {
// 获取文件路径
String filepath = Test.class.getClassLoader().getResource("stu.txt").getFile();
// 获得FileChannel
try (FileChannel channel = new FileInputStream(filepath).getChannel()) {
// 获得缓冲区
ByteBuffer buffer = ByteBuffer.allocate(10);
StringBuilder builder = new StringBuilder();
// 创建一个 flag
int hasNext = 0;
// 把通道里面的数据写入 buffer(这个 read 读取的字节数,如果通道已到达流尾,则可能为 0 或 -1)
while ((hasNext = channel.read(buffer)) > 0) {
// 切换模式(读模式) limit=position, position=0
buffer.flip();
// 当 buffer 中还有数据时,获取其中的数据(就是从 buffer 里面取得数据)
while (buffer.hasRemaining()) {
// 把当前字符写入 StringBuilder 里面(注意,中文字符不止一个 char 大小)
builder.append((char) buffer.get());
}
// 切换模式 position=0, limit=capacity
buffer.clear(); // 切换为写模式
}
System.out.println(builder.toString());
} catch (IOException e) {
System.out.println(e);
}
}
}

核心属性

字节缓冲区的父类 Buffer 中有几个核心属性,如下

// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;

capacity:缓冲区的容量,通过构造函数赋予,一旦设置,无法更改 limit:缓冲区的界限,位于 limit 后的数据不可读写。缓冲区的限制不能为负,并且不能大于其容量 position:下一个读写位置的索引(类似PC),缓冲区的位置不能为负,并且不能大于 limit mark:记录当前 position 的值,position 被改变后,可以通过调用 reset() 方法恢复到 mark 的位置。

以上四个属性必须满足以下要求

mark <= position <= limit <= capacity

ByteBuffer 结构

ByteBuffer 有以下重要属性

  • capacity
  • position
  • limit

一开始

写模式下,position 是写入位置,limit 等于容量,下图表示写入了 4 个字节后的状态

flip 动作发生后,position 切换为读取位置,limit 切换为读取限制

读取 4 个字节后,状态

这里特别介绍一下以下两个方法:

clear 动作发生后,状态

compact 方法,是把未读完的部分向前压缩,然后切换至写模式(注意!!写模式和读模式的 position 指针位置是不同的,写模式它表示下一个写入的位置,读模式是下一个读取的位置,至于写模式的情况下,读模式的下标在哪里,请看 flip 方法)

Buffer 的核心方法

put 写数据

put() 方法可以将一个数据放入到缓冲区中。 进行该操作后,postition 的值会 +1,指向下一个可以放入的位置。capacity = limit,为缓冲区容量的值。

flip 切换模式

flip() 方法会 切换对缓冲区的操作模式,由 写模式=>读模式 / 读模式=>写模式 进行该操作后

  • 如果是 写=>读,position = 0,limit 指向最后一个元素的下一个位置,capacity 不变
  • 如果是 读=>写,则恢复为 put() 方法中的值

所以切换模式后最大的改变就是这个 position 下标的位置改变,以及切为读模式时,通过 limit 保存当前写模式时 position 的位置

get 取得数据

  • get() 方法会读取缓冲区中的一个值
  • 进行该操作后,position 会 +1,如果超过了 limit 则会抛出异常
  • 注意:get(i) 方法不会改变 position 的值

rewind 恢复上一步

  • 该方法只能在读模式下使用
  • rewind() 方法后,会恢复 position、limit 和 capacity 的值,变为进行 get() 前的值

clean 初始化

  • clean() 方法会将缓冲区中的各个属性恢复为最初的状态,position = 0, capacity = limit
  • 此时缓冲区的数据依然存在,处于 “被遗忘” 状态,下次进行写操作时会覆盖这些数据

mark 和 reset 保持点

mark() 方法会将 postion 的值保存到 mark 属性中 reset() 方法会将 position 的值改为 mark 中保存的值

compact 压缩

此方法为 ByteBuffer 的方法,而不是 Buffer 的方法

  • compact 会把未读完的数据向前压缩,然后切换到写模式
  • 数据前移后,原位置的值并未清零,写时会覆盖之前的值

注意!!写模式和读模式的 position 指针位置是不同的,写模式它表示下一个写入的位置,读模式是下一个读取的位置,至于写模式的情况下,读模式的下标在哪里,请看 flip 方法

如上将所有未读数据拷贝到 Buffer 起始处。然后将 position 设到最后一个未读元素正后面。

limit 属性依然像 clear() 方法一样,设置成 capacity。 现在 Buffer 准备好写数据了,但是不会覆盖未读数据。

clear VS compact

clear 只是对 position、limit、mark 进行重置,而 compact 在对 position 进行设置,以及 limit、mark 进行重置的同时,还涉及到数据在内存中拷贝(会调用 arraycopy)。

所以 compact 比 clear 更耗性能。

但 compact 能保存你未读取的数据,将新数据追加到为读取的数据之后; 而 clear 则不行,若你调用了 clear,则未读取的数据就无法再读取到了

所以需要根据情况来判断使用哪种方法进行模式切换

Reference

参考资料 Java NIO浅析 参考资料 Java NIO?看这一篇就够了! 参考资料 如何理解BIO、NIO、AIO的区别? 参考资料 16.BIO、NIO、AIO 有什么区别?